🎖️GitЯра🎖️
Commit 83ed71d452ee7bd23a1ebbfe0f6d42b913fe6ae7
Parents : 137a85a
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-21T15:44:01-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-21T20:44:01Z
fix(map): allow editing/deleting your own locked waypoint (#6344)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Changes
7 files changed, 148 insertions(+), 24 deletions(-)
Diff
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index 155f645727..f23603f5bc 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -97,6 +97,8 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.geofence.toGeofence
+import org.meshtastic.core.model.isLocked
+import org.meshtastic.core.model.isModifiableBy
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
import org.meshtastic.core.resources.cancel
@@ -479,9 +481,8 @@ fun MapView(
// Foreign geofences: read-only view hosting the receiver-local crossing-alert opt-in.
waypoint.toGeofence() != null && !mapViewModel.isMyWaypoint(id) -> showGeofenceInfoDialog = waypoint
- // edit only when unlocked or lockedTo myNodeNum
- waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected ->
- showEditWaypointDialog = waypoint
+ // edit only when unlocked or locked to us
+ waypoint.isModifiableBy(mapViewModel.myNodeNum) && isConnected -> showEditWaypointDialog = waypoint
else -> showDeleteWaypointDialog = waypoint
}
@@ -498,7 +499,7 @@ fun MapView(
return waypoints.mapNotNull { waypoint ->
val pt = waypoint.waypoint ?: return@mapNotNull null
if (!mapFilterState.showWaypoints) return@mapNotNull null // Use collected mapFilterState
- val lock = if (pt.locked_to != 0) "\uD83D\uDD12" else ""
+ val lock = if (pt.isLocked) "\uD83D\uDD12" else ""
val time = DateFormatter.formatDateTime(waypoint.time)
val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt(), unknownText, nowText)
val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
@@ -894,6 +895,7 @@ fun MapView(
EditWaypointDialog(
waypoint = showEditWaypointDialog ?: return, // Safe call
displayUnits = displayUnits,
+ myNodeNum = mapViewModel.myNodeNum,
onSend = { waypoint ->
Logger.d { "User clicked send waypoint ${waypoint.id}" }
showEditWaypointDialog = null
@@ -901,18 +903,10 @@ fun MapView(
val newId = if (waypoint.id == 0) mapViewModel.generatePacketId() else waypoint.id
val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name
val newExpire = if (waypoint.expire == 0) Int.MAX_VALUE else waypoint.expire
- val newLockedTo = if (waypoint.locked_to != 0) mapViewModel.myNodeNum ?: 0 else 0
val newIcon = if (waypoint.icon == 0) 128205 else waypoint.icon
- mapViewModel.sendWaypoint(
- waypoint.copy(
- id = newId,
- name = newName,
- expire = newExpire,
- locked_to = newLockedTo,
- icon = newIcon,
- ),
- )
+ // locked_to is already resolved by the editor (our node number when locked, 0 when not).
+ mapViewModel.sendWaypoint(waypoint.copy(id = newId, name = newName, expire = newExpire, icon = newIcon))
},
onDelete = { waypoint ->
Logger.d { "User clicked delete waypoint ${waypoint.id}" }
@@ -943,7 +937,7 @@ fun MapView(
// Unlocked foreign geofences can still be edited/re-broadcast (only while connected, since editing means
// re-sending); locked ones stay read-only.
onEdit =
- if (waypoint.locked_to == 0 && isConnected) {
+ if (!waypoint.isLocked && isConnected) {
{
showGeofenceInfoDialog = null
showEditWaypointDialog = waypoint
@@ -956,7 +950,7 @@ fun MapView(
if (showDeleteWaypointDialog != null) {
val waypoint = showDeleteWaypointDialog ?: return
- val canDeleteForEveryone = waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected
+ val canDeleteForEveryone = waypoint.isModifiableBy(mapViewModel.myNodeNum) && isConnected
androidx.compose.material3.AlertDialog(
onDismissRequest = {
Logger.d { "User canceled marker delete dialog" }
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index b1f22cfe6d..eabcf61f1f 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -125,6 +125,8 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.geofence.toGeofence
+import org.meshtastic.core.model.isLocked
+import org.meshtastic.core.model.isModifiableBy
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
import org.meshtastic.core.model.util.metersIn
@@ -715,6 +717,7 @@ fun MapView(
EditWaypointDialog(
waypoint = waypointToEdit,
displayUnits = displayUnits,
+ myNodeNum = myNodeNum,
onSend = { updatedWp ->
var finalWp = updatedWp
if (updatedWp.id == 0) {
@@ -727,7 +730,9 @@ fun MapView(
editingWaypoint = null
},
onDelete = { wpToDelete ->
- if (wpToDelete.locked_to == 0 && isConnected && wpToDelete.id != 0) {
+ // Broadcast the removal (expire=1) only for waypoints we're allowed to modify mesh-wide
+ // (unlocked, or locked to us); otherwise just drop our local copy below.
+ if (wpToDelete.isModifiableBy(myNodeNum) && isConnected && wpToDelete.id != 0) {
mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1))
}
mapViewModel.deleteWaypoint(wpToDelete.id)
@@ -754,7 +759,7 @@ fun MapView(
// Unlocked foreign geofences can still be edited/re-broadcast (only while connected, since editing
// means re-sending); locked ones stay read-only.
onEdit =
- if (waypoint.locked_to == 0 && isConnected) {
+ if (!waypoint.isLocked && isConnected) {
{
geofenceInfoWaypoint = null
editingWaypoint = waypoint
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
index 73b66698b7..50a6d0007d 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
@@ -34,6 +34,8 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.convertIntToEmoji
import org.meshtastic.core.model.geofence.toGeofence
+import org.meshtastic.core.model.isLocked
+import org.meshtastic.core.model.isModifiableBy
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.geofence
@@ -87,10 +89,15 @@ fun WaypointMarkers(
description
}
+ // Lock cue in the info-window title (parity with the fdroid marker), so a locked waypoint is
+ // identifiable rather than only surfacing as a "locked" toast on tap.
+ val cleanName = waypoint.name.replace('\n', ' ').replace('\b', ' ')
+ val title = if (waypoint.isLocked) "${convertIntToEmoji(LOCK)} $cleanName" else cleanName
+
Marker(
state = markerState,
icon = icon,
- title = waypoint.name.replace('\n', ' ').replace('\b', ' '),
+ title = title,
snippet = snippet,
visible = true,
onInfoWindowClick = {
@@ -98,8 +105,7 @@ fun WaypointMarkers(
// Foreign geofences: read-only view hosting the receiver-local crossing-alert opt-in.
waypoint.toGeofence() != null && !isMyWaypoint(waypoint.id) -> onShowGeofenceInfo(waypoint)
- waypoint.locked_to == 0 || waypoint.locked_to == myNodeNum || !isConnected ->
- onEditWaypointRequest(waypoint)
+ waypoint.isModifiableBy(myNodeNum) || !isConnected -> onEditWaypointRequest(waypoint)
else -> scope.launch { context.showToast(Res.string.locked) }
}
@@ -110,3 +116,4 @@ fun WaypointMarkers(
}
private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin
+private const val LOCK = 0x1F512 // Unicode for Lock
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 986a6abb43..f97ff1fdca 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -35,6 +35,7 @@ import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.destination
import org.meshtastic.core.model.isBroadcast
import org.meshtastic.core.model.isFromLocal
+import org.meshtastic.core.model.isModifiableBy
import org.meshtastic.core.model.source
import org.meshtastic.core.model.textMentionsNode
import org.meshtastic.core.model.util.MeshDataMapper
@@ -288,7 +289,8 @@ class MeshDataHandlerImpl(
) {
val payload = packet.decoded?.payload ?: return
val u = Waypoint.ADAPTER.decode(payload)
- if (u.locked_to != 0 && u.locked_to != packet.from) return
+ // A locked waypoint may only be created/updated by its owner; drop it if the sender isn't allowed to modify it.
+ if (!u.isModifiableBy(packet.from)) return
val currentSecond = nowSeconds.toInt()
rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond, session = session)
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/WaypointLock.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/WaypointLock.kt
new file mode 100644
index 0000000000..100a8eaaa5
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/WaypointLock.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model
+
+import org.meshtastic.proto.Waypoint
+
+/**
+ * Whether this waypoint is locked to a single node. A locked waypoint (`locked_to != 0`) may only be edited or removed
+ * mesh-wide by the node it is locked to; an unlocked waypoint (`locked_to == 0`) is editable by anyone.
+ */
+val Waypoint.isLocked: Boolean
+ get() = locked_to != 0
+
+/**
+ * Whether [nodeNum] is permitted to edit this waypoint or broadcast a change/removal of it: true when the waypoint is
+ * unlocked, or locked to exactly [nodeNum]. A null [nodeNum] (our own identity not yet known) can only modify unlocked
+ * waypoints.
+ *
+ * Single source of truth for the `locked_to` permission check. Callers pass the identity being checked:
+ * - map edit/delete gates pass our own node number, so the creator can always manage a waypoint locked to themselves;
+ * - the inbound-packet validator passes the packet's sender, so a locked waypoint is only accepted from its owner.
+ *
+ * The lock owner must be a real node number. Writing a placeholder (e.g. `1`) would lock a waypoint to a phantom node
+ * that no real device can match, silently blocking even the creator — see issue #6343.
+ */
+fun Waypoint.isModifiableBy(nodeNum: Int?): Boolean = locked_to == 0 || locked_to == nodeNum
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/WaypointLockTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/WaypointLockTest.kt
new file mode 100644
index 0000000000..4d1da75c51
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/WaypointLockTest.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model
+
+import org.meshtastic.proto.Waypoint
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class WaypointLockTest {
+
+ private val myNodeNum = 0x433d0d3c
+ private val otherNodeNum = 0x1a2b3c4d
+
+ @Test
+ fun unlockedWaypointIsNotLocked() {
+ assertFalse(Waypoint(id = 1, locked_to = 0).isLocked)
+ }
+
+ @Test
+ fun lockedWaypointIsLocked() {
+ assertTrue(Waypoint(id = 1, locked_to = myNodeNum).isLocked)
+ }
+
+ @Test
+ fun unlockedWaypointIsModifiableByAnyone() {
+ val wp = Waypoint(id = 1, locked_to = 0)
+ assertTrue(wp.isModifiableBy(myNodeNum))
+ assertTrue(wp.isModifiableBy(otherNodeNum))
+ // Editable by anyone includes the "identity unknown" case.
+ assertTrue(wp.isModifiableBy(null))
+ }
+
+ @Test
+ fun lockedWaypointIsModifiableOnlyByOwner() {
+ val wp = Waypoint(id = 1, locked_to = myNodeNum)
+ assertTrue(wp.isModifiableBy(myNodeNum))
+ assertFalse(wp.isModifiableBy(otherNodeNum))
+ assertFalse(wp.isModifiableBy(null))
+ }
+
+ /**
+ * Regression for #6343: the "locked" toggle used to write the placeholder `1` instead of the creator's node number.
+ * A placeholder never equals a real node number, so the creator's own edit/delete gates all failed. Locking to the
+ * real node number is what lets the owner manage the waypoint.
+ */
+ @Test
+ fun placeholderLockShutsOutTheCreatorButRealOwnerLockDoesNot() {
+ assertFalse(Waypoint(id = 1, locked_to = 1).isModifiableBy(myNodeNum))
+ assertTrue(Waypoint(id = 1, locked_to = myNodeNum).isModifiableBy(myNodeNum))
+ }
+}
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
index 49dfeaffc8..e0be267cf4 100644
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
@@ -75,6 +75,7 @@ import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.model.geofence.GeofenceRadiusPresets
+import org.meshtastic.core.model.isLocked
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
@@ -117,6 +118,7 @@ import kotlin.time.Duration.Companion.hours
fun EditWaypointDialog(
waypoint: Waypoint,
displayUnits: DisplayUnits,
+ myNodeNum: Int?,
onSend: (Waypoint) -> Unit,
onDelete: (Waypoint) -> Unit,
onDismissRequest: () -> Unit,
@@ -224,8 +226,16 @@ fun EditWaypointDialog(
Text(stringResource(Res.string.locked))
}
Switch(
- checked = waypointInput.locked_to != 0,
- onCheckedChange = { waypointInput = waypointInput.copy(locked_to = if (it) 1 else 0) },
+ checked = waypointInput.isLocked,
+ // Lock to our own node so we (and only we) can later edit or remove it; unlock clears the
+ // owner. locked_to must be a real node number — a placeholder locks the waypoint to a
+ // phantom node and blocks even the creator (see #6343). Disabled until our node number is
+ // known, since without it we can't lock a waypoint to ourselves (the toggle would silently
+ // stay unlocked).
+ enabled = myNodeNum != null,
+ onCheckedChange = { locked ->
+ waypointInput = waypointInput.copy(locked_to = if (locked) myNodeNum ?: 0 else 0)
+ },
)
}
Spacer(modifier = Modifier.size(8.dp))
Served by rngit 1.5.0 - Generated in 0.1s